昨天,我們完成了遊戲核心的 90% 內容,今天我們要來添加遊戲的起點與終點,讓玩家可以從主頁面進入遊戲,並且在遊戲結束後返回主頁面。
在遊戲開發中,場景管理是一個常見的需求,尤其是當遊戲有多個不同的場景(如主頁面、遊戲場景、設定頁面等)時。我們需要一個機制來切換這些場景,並且確保每個場景都有自己的生命週期(如初始化、更新、銷毀等)。
有很多種方式可以實現場景管理,像是使用狀態機、事件系統或是簡單的顯示/隱藏等。由於我們的遊戲並沒有太複雜的場景需要管理,目前預計就只有主頁面以及遊戲場景兩個,因此我打算用一個最簡單的方式來控制我們的場景。
我打算建構一個 BaseScene 類別,讓所有的場景都繼承自這個類別,並且規定好一些基本的功能,如開啟、關閉、動畫等。這樣我們就可以確保每個場景都有一致的行為,並且可以輕鬆地切換場景。
除此之外,每個繼承自 BaseScene 的場景類別,會根據自身的需求來添加對應的回調函數,例如主頁面因為有點擊開始遊戲的需求,因此會有 onStart 回調函數;遊戲場景因為有返回主頁的需求,因此會有 onBack 回調函數。
但是,場景本身並不負責實作這些回調函數的內部細節,他們只負責在對應的時機呼叫這些回調函數,至於回調函數要做什麼事情,將會由我們在 app.ts 裡面來實作。
這樣的設計可以讓場景類別保持簡潔,並且讓我們可以在 app.ts 裡面集中管理場景之間的切換邏輯。
BaseScene:場景基類接下來,我們先來實作 BaseScene 類別,這個類別會繼承自 PIXI.Container,並且提供基本的開啟與關閉動畫功能。
// Scenes/BaseScene.ts
import { TweenUtil } from "../Utils/TweenUtil";
/** 基本場景類別 */
export class BaseScene extends PIXI.Container {
    private _isAnimating: boolean = false;
    constructor() {
        super();
        // 預設隱藏場景
        this.visible = false;
    }
    /** 是否正在執行開啟或關閉動畫 */
    get isAnimating(): boolean { return this._isAnimating; }
    /** 開啟場景 */
    async open(): Promise<void> {
        if (this._isAnimating) return;
        this._isAnimating = true;
        this.visible = true;
        await this._open();
        this._isAnimating = false;
    }
    /** 開啟場景的具體動畫實現 */
    protected async _open(): Promise<void> {
        this.alpha = 0;
        await TweenUtil.to<PIXI.Container>(this, { alpha: 1 }, 300, TWEEN.Easing.Cubic.Out);
    }
    /** 關閉場景 */
    async close(): Promise<void> {
        if (this._isAnimating) return;
        this._isAnimating = true;
        await this._close();
        this.visible = false;
        this._isAnimating = false;
    }
    /** 關閉場景的具體動畫實現 */
    protected async _close(): Promise<void> {
        await TweenUtil.to<PIXI.Container>(this, { alpha: 0 }, 300, TWEEN.Easing.Cubic.Out);
    }
}
_isAnimating 屬性來追蹤場景是否正在執行開啟或關閉動畫,避免重複呼叫。open() 與 close() 方法來開啟與關閉場景,並且會呼叫對應的動畫實現方法 _open() 與 _close()。有了最基礎的 BaseScene 之後,接下來就是要分支出 GameScene、MenuScene 了。
GameScene:遊戲場景遊戲場景會負責管理 Game 的實例,這個工作我們原本是交給 app.ts 裡的 start() 負責,現在我們把他拉進遊戲場景內。
// Scenes/GameScene.ts
import { BaseScene } from "./BaseScene";
import { Game } from './../Games/Game';
/** 遊戲場景 */
export class GameScene extends BaseScene {
    private _game: Game;
    /** 當遊戲按下返回主頁時 */
    onBack: () => void;
    /** 設定並開始遊戲 */
    _setupGame(): void {
        if (this._game) return;
        const game = this._game = new Game();
        this.addChild(game);
        // 監聽遊戲按下返回主頁按鈕時
        game.once(Game.EVENT.BACK, () => {
            if (this.isAnimating) return;
            this.onBack?.();
        });
        // 監聽遊戲按下重新開始按鈕時
        game.once(Game.EVENT.RESTART, () => {
            if (this.isAnimating) return;
            this.clear();
            this._setupGame();
        });
        game.start();
    }
    /** 清除遊戲 */
    clear(): void {
        this._game?.destroy();
        this._game = null;
    }
    protected async _open(): Promise<void> {
        this._setupGame(); // 設置並開始遊戲
        await super._open();
    }
    protected async _close(): Promise<void> {
        await super._close();
        this.clear(); // 清除遊戲
    }
}
_game 屬性來保存 Game 的實例,並且在 _setupGame() 方法中初始化它。Game 的 BACK 與 RESTART 事件,並且呼叫對應的回調函數或重新開始遊戲。_open() 方法中呼叫 _setupGame() 來初始化並開始遊戲。_close() 方法中呼叫 clear() 來銷毀遊戲實例,釋放資源。MenuScene:主頁面場景最後是主頁面場景,這個場景會顯示遊戲的封面,並且有一個按鈕讓玩家開始遊戲。
// Scenes/MenuScene.ts
import { BaseScene } from "./BaseScene";
import pixi = CG.Pixi.pixi;
import { TweenUtil } from './../Utils/TweenUtil';
import { soundManager } from './../Utils/SoundManager';
/** 主頁面場景 */
export class MenuScene extends BaseScene {
    private _titleText: PIXI.Text;
    private _tipContainer: PIXI.Container;
    private _tipText: PIXI.Text;
    private _startTime: number;
    constructor() {
        super();
        // ... (其餘初始化背景圖、提示文字等,暫且省略)
        // 創建漸變色
        const gradient = new PIXI.FillGradient({
            type: "linear",
            colorStops: [
                { offset: 0, color: 0xFDF3AE },
                { offset: 1, color: 0xF6B853 },
            ],
        });
        const title = this._titleText = new PIXI.Text({
            text: "小女巫・啟程",
            style: {
                fontFamily: "Source Han Serif TW Heavy",
                fontSize: 60,
                fontWeight: "bold",
                fill: gradient, // 使用漸變色
                stroke: {
                    color: 0x5E1042,
                    width: 6,
                    join: "round",
                } as PIXI.StrokeStyle
            },
            anchor: 0.5,
            position: { x: pixi.stageWidth * 0.3, y: pixi.stageHeight * 0.2 }
        } as PIXI.TextOptions);
        this.addChild(title);
        // 設置點擊事件
        this.eventMode = "static";
        this.on("pointertap", this._onClick, this);
    }
    /** 當點擊開始遊戲時 */
    onStart: () => void;
    /** 當場景被點擊時 */
    private _onClick(): void {
        if (this.isAnimating) return;
        this.onStart?.();
    }
    protected async _open(): Promise<void> {
        this.alpha = 0;
        this._titleText.alpha = 0;
        this._titleText.scale.set(3);
        this._tipContainer.alpha = 0;
        soundManager.playMusic("menu.ogg"); // 播放背景音樂
        await TweenUtil.to<MenuScene>(this, { alpha: 1 }, 500, TWEEN.Easing.Sinusoidal.Out);
        await Promise.all([
            TweenUtil.to(this._titleText, { alpha: 1 }, 1000, TWEEN.Easing.Quintic.In),
            TweenUtil.to(this._titleText.scale, { x: 1, y: 1 }, 1000, TWEEN.Easing.Quintic.In),
        ]);
        await CG.Base2.wait(300);
        this._startTime = Date.now();
        CG.Base2.addUpdateFunction(this, this._update);
        await TweenUtil.to(this._tipContainer, { alpha: 1 }, 500, TWEEN.Easing.Cubic.Out);
    }
    protected async _close(): Promise<void> {
        CG.Base2.removeUpdateFunction(this, this._update);
        soundManager.fadeOutMusic(500); // 淡出背景音樂
        return super._close();
    }
    /** 更新循環函數 */
    private _update(): void {
        // 讓提示文字有縮放動畫
        const elapsedTime = Date.now() - this._startTime;
        const timing = elapsedTime % 2000 / 2000;
        const scale = Math.sin(timing * Math.PI * 2);
        this._tipText.scale.set(1 + scale * 0.02);
    }
}
FillGradient 是 PixiJS 用來建立、管理漸層填色的類別,這邊利用它來讓標題文字更有質感。_open() 方法中播放背景音樂,並且播放標題與提示文字的動畫。_close() 方法中停止更新函數,並且淡出背景音樂。_update() 方法來讓提示文字有縮放動畫,增加一些動態效果。
FillGradient是 PixiJS v8 新增的功能,早期如果你想要讓文字有漸層顏色的話,會直接在fill屬性帶入顏色陣列,像是fill: [0xFDF3AE, 0xF6B853]。

app.ts:管理場景切換最後,我們需要在 app.ts 裡面來管理場景之間的切換,這邊就不貼整個 start() 函數了,主要展示場景切換的部分。
// ... (其他 import 程式碼,暫且省略)
async function start() {
	// ... (載入資源、初始化 pixi 等,暫且省略)
	// 主頁面場景
	const menuScene = new MenuScene();
	pixi.root.addChild(menuScene);
	// 遊戲場景
	const gameScene = new GameScene();
	pixi.root.addChild(gameScene);
	// 設置主頁面場景點擊開始遊戲時
	menuScene.onStart = async () => {
		await menuScene.close();
		await gameScene.open();
	};
	// 設置遊戲場景按下返回主頁時
	gameScene.onBack = async () => {
		await gameScene.close();
		await menuScene.open();
	};
	// 開始時先顯示主頁面場景
	menuScene.open();
}
start();
start() 函數中初始化 MenuScene 與 GameScene,並且將它們添加到 pixi.root。menuScene.onStart 與 gameScene.onBack 回調函數,來切換場景。
今天我們完成了遊戲的起點與終點,讓玩家可以從主頁面進入遊戲,並且在遊戲結束後返回主頁面:
BaseScene 類別來規範場景的行為,並且讓每個場景都能夠輕鬆地切換。GameScene 類別,負責管理 Game 的實例,並且處理遊戲的開始與結束。MenuScene 類別,顯示遊戲的封面,並且提供一個按鈕讓玩家開始遊戲。app.ts 裡面管理場景之間的切換,確保玩家能夠順利地從主頁面進入遊戲,並且在遊戲結束後返回主頁面。到此為止,我們的《小女巫・啟程》遊戲已經完成了主要的功能與流程。原本昨天說有餘裕的話可以加個開場動畫,不過後來想想除了遊戲開發進入尾聲,我們這個 30 天的旅程也要接近尾聲了,因此我就不再拉長整個文章的篇幅了。
明天,我打算來優化遊戲的難度曲線、平衡性,以及進行一些最後的調整與優化,讓整個遊戲更加完善。